from pdb import set_trace
from pprint import pprint

class Rule(object):
    def __init__(self, updates, depends, recipe):
        self.updates = updates
        self.depends = depends
        self.recipe = recipe
        assert set(updates).isdisjoint(set(depends)), "Rule depends on itself:\n" + str(self)
    def __str__(self):
        from subprocess import list2cmdline
        return "\n\t".join([
                list2cmdline(self.updates) + " : " + list2cmdline(self.depends)
            ] + map(str, self.recipe)) + "\n"
    def __repr__(self):
        return "{0}({1}, {2}, {3})".format(
            "pybake.Rule",
            self.updates,
            self.depends,
            self.recipe)
    def stale(self):
        from os.path import getmtime
        from errno import ENOENT
        try:
            upstamps = [getmtime(u) for u in self.updates]
        except OSError as exc:
            if exc.errno == ENOENT:
                return True
            raise
        try:
            depstamps = [getmtime(d) for d in self.depends]
        except OSError as exc:
            if exc.errno == ENOENT:
                return True
            raise
        return depstamps and max(depstamps) > min(upstamps)
    def __le__(self, other):
        return set(self.updates) & set(other.depends)

class Feedback(object):
    def __init__(self, msg, error=None, exit=False, done=None):
        self.msg = msg
        self.error = error
        self.exit = exit
        self.done = done

class Rules(object):
    def __init__(self):
        self.rules = set()
        self.targets = {}
        self.names = {}
    def add(self, updates, depends, recipe, name=None):
        r = Rule(updates, depends, recipe)
        for u in updates:
            assert u not in self.targets, "Rules share targets\n" + str(self.targets[u]) + str(r)
            self.targets[u] = r
        if name is not None:
            assert name not in self.names
            self.names[name] = r
        self.rules.add(r)
    def children(self, rule):
        retval = set()
        for dep in rule.depends:
            if dep in self.targets:
                retval.add(self.targets[dep])
        return retval
    def add_gen(self, gen):
        self.rulegens.append(gen)
    def __str__(self):
        return "".join(map(str, self.rules))

class NoRuleError(Exception):
    def __init__(self, target):
        self.target = target
        self.rules = []
    def add_rule(self, rule):
        self.rules.append(rule)
    def __str__(self):
        return "Required by:\n".join(["No rule exists to create %r\n" % self.target] + map(str, self.rules))

def make_stale(stale, options):
    threads = []
    from threading import Thread, Lock, Condition
    from Queue import Queue
    feedback_queue = Queue()
    def feedback(*pargs, **kwargs):
        feedback_queue.put(Feedback(*pargs, **kwargs))
    for job in range(1, options.jobs + 1):
        thrd = Thread(target=job_routine, name="Job %u"%(job),
            args=(feedback, stale, options))
        thrd.daemon = True
        threads.append(thrd)
        thrd.start()
    from traceback import print_exception
    from sys import stdout, stderr
    while len(threads):
        fdbk = feedback_queue.get()
        if fdbk.msg is not None:
            assert fdbk.error is not None
            strm = stderr if fdbk.error else stdout
            strm.write(fdbk.msg)
        else:
            assert fdbk.error is None
        if fdbk.exit:
            raise SystemExit(fdbk.exit)
        if fdbk.done:
            fdbk.done.join()
            threads.remove(fdbk.done)

def add_stale(rules, options, stale, target, arg):
    if target in rules.names:
        if target in rules.targets:
            assert rules.names[target] == rules.targets[target]
        rule = rules.names[target]
    elif target in rules.targets:
        rule = rules.targets[target]
    else:
        assert not arg, "There is no rule to make explicit target %r" % target
        from os.path import exists
        if not exists(target):
            raise NoRuleError(target)
        return False
    if rule in stale:
        return True
    for ruledep in rule.depends:
        try:
            if add_stale(rules, options, stale, ruledep, False):
                stale.add(rule)
        except NoRuleError as exc:
            exc.add_rule(rule)
            raise exc
    if rule not in stale and (options.always_make or rule.stale()):
        stale.add(rule)
    return rule in stale

class Stale(object):
    def __init__(self, rules, stale, jobs):
        self._rules = rules
        self._stale = stale
        self._updating = set()
        from threading import Lock, Condition, BoundedSemaphore
        self._lock = Lock()
        self._cond = Condition(self._lock)
        self._waiting = BoundedSemaphore(jobs - 1)
    def get(self):
        with self._lock:
            while True:
                for rule in self._stale:
                    if not self._rules.children(rule) & (self._stale | self._updating):
                        self._updating.add(rule)
                        self._stale.remove(rule)
                        return rule
                if not len(self._stale):
                    return None
                assert self._waiting.acquire(blocking=False)
                self._cond.wait()
                self._waiting.release()
    def done(self, rule):
        with self._lock:
            self._updating.remove(rule)
            self._cond.notify_all()

def make_arg(rules, options, targets, direct):
    stale = set()
    for a in targets:
        add_stale(rules, options, stale, a, direct)
    make_stale(Stale(rules, stale, options.jobs), options)

def make(rules, options, targets=None):
    if options.print_rules:
        for r in rules.rules:
            print r
    for a in targets or ["all"]:
        if a == "all":
            make_arg(rules, options, rules.targets.keys(), False)
        elif a == "clean":
            from os import remove
            from errno import ENOENT
            for b in sorted(rules.targets.keys()):
                try:
                    remove(b)
                except OSError as exc:
                    if exc.errno != ENOENT:
                        from sys import stderr
                        print >>stderr, "Error removing %r" % b, exc
                        raise SystemExit(1)

                else:
                    print "Removed %r" % b
        else:
            make_arg(rules, options, [a], True)
        print "Target %r up to date." % a

class RecipeStep(object):
    pass

class Command(RecipeStep):
    def __init__(self, args):
        pass

def job_routine(feedback, stale, options):
    # perhaps importing this early will prevent NoneType errors during shutdown
    from threading import current_thread
    try:
        from subprocess import Popen, check_call, PIPE, list2cmdline
        while True:
            rule = stale.get()
            if rule is None:
                return
            for cmd in rule.recipe:
                #feedback(repr(cmd), error=True)
                #if not options.quiet
                feedback(list2cmdline(cmd)+"\n", False)
                proc = Popen(cmd, stdout=PIPE, stderr=PIPE)
                stdoutdata, stderrdata = proc.communicate()
                feedback(stderrdata, False)
                feedback(stdoutdata, False)
                if proc.returncode:
                    feedback(
                        "Command '%s' returned non-zero exit status %d\n" % (
                            cmd, proc.returncode),
                        True, exit=True)
            if rule.stale():
                feedback(
                    msg="Rule still stale after recipe executed:\n" + str(rule),
                    error=True,
                    exit=True)
            stale.done(rule)
    except:
        from traceback import format_exc
        feedback(format_exc(), True, exit=True)
    finally:
        feedback(None, done=current_thread())

def default_job_count():
    from multiprocessing import cpu_count
    # for 1-2 cores, maybe return just that many, but i believe there is a
    # little extra overhead than for make, so an extra job is justified
    return cpu_count() + 1

class Config(object):
    def __init__(self):
        self._vars = {}
    def add_var(self, name, value):
        self._vars[name] = value
    def __getattr__(self, name):
        return self._vars[name]

def parse_args():
    from optparse import OptionParser
    parser = OptionParser()
    parser.add_option("-B", "--always-make", action="store_true")
    parser.add_option("-j", "--jobs", type="int", default=default_job_count())
    parser.add_option("-p", "--print-rules", action="store_true", default=False)
    #parser.add_option("-c", "--config")
    options, args = parser.parse_args()
    config = Config()
    targets = []
    from shlex import split
    for a in args:
        try:
            name, value = a.split("=", 1)
        except ValueError:
            targets.append(a)
        else:
            config.add_var(name, split(value))
    return config, options, targets

def parse_make_rule(rule):
    import re
    retval = rule
    # concatenate lines
    retval = retval.replace("\\\n", "")
    # break into targets: depends
    targs, deps = retval.split(":", 1)
    # split allowing for whitespace escapes
    targs, deps = [ filter(None, re.split(r"(?<!\\)\s", x)) for x in [targs, deps] ]
    #pdb.set_trace()
    return targs, deps

class rulegen(object):
    def __init__(self, **initial):
        object.__setattr__(self, "_values", self._params.copy())
        self.set(**initial)
    def __getattr__(self, name):
        return self._values[name]
    def __setattr__(self, name, value):
        default = self._params[name]
        self._values[name] = type(default)(value)
            #raise AttributeError("%r has no param %r" % (self.__class__.__name__, name))
    def set(self, **clobber):
        for a, b in clobber.iteritems():
            setattr(self, a, b)
    def copy(self, **clobber):
        a = self.__class__(**self._values)
        a.set(**clobber)
        return a
    def add_to(self, rules):
        raise NotImplementedError

class cxobject(rulegen):
    _params = {
        "cxflags": [],
        "cflags": [],
        "cxxflags": [],
        "defines": [],
        "incdirs": [],
        "source": "",
        "language": "",
        "object": "",
        "depfile": "",
    }
    @property
    def _depfile(self):
        from os.path import splitext
        return self._values["depfile"] or splitext(self.source)[0] + ".d"
    @property
    def _object(self):
        from os.path import splitext
        return self._values["depfile"] or splitext(self.source)[0] + ".o"
    @property
    def _language(self):
        return self._values["language"] or "c" if self.source.endswith(".c") else "c++"
    @property
    def _cflags(self):
        from os import environ
        from shlex import split
        return self.cflags + split(environ.get("CFLAGS", ""))
    @property
    def _cxxflags(self):
        from os import environ
        from shlex import split
        return self.cxxflags + split(environ.get("CXXFLAGS", ""))
    @property
    def _cxflags(self):
        from os import environ
        from shlex import split
        return self.cxflags \
            + {"c": self._cflags, "c++": self._cxxflags}[self._language] \
            + split(environ.get("CXFLAGS", ""))
    def add_to(self, rules):
        depends = [self.source]
        from os.path import exists
        from sys import stderr
        if exists(self._depfile):
            hdepdata = open(self._depfile).read()
            if hdepdata:
                mktargs, mkdeps = parse_make_rule(hdepdata)
                if mktargs != [self._object]:
                    print >>stderr, "Header dep file has unexpected targets %r" % (mktargs)
                elif mkdeps[0] != self.source:
                    print >>stderr, "Header dep file has unexpected source dependency %r" % depends[0]
                else:
                    depends = mkdeps
                del mktargs, mkdeps
        rules.add([self._object, self._depfile], depends, [
            ["gcc", "-o", self._object, "-c", self.source, "-MMD"]
            + ["-I"+a for a in self.incdirs or []]
            + ["-D"+a for a in self.defines or []]
            + list(self._cxflags)])

class cxproject(rulegen):
    _params = {
            "sources": [],
            "sourcedir": None,
            "kind": "console",
            "incdirs": [],
            "cflags": [],
            "cxxflags": [],
            "cxflags": [],
            "defines": [],
            "ldflags": [],
            "objects": [],
            "language": "",
            "libraries": [],
            "name": "",
            "target": ""
        }
    @property
    def _sources(self):
        from itertools import chain
        from glob import glob
        from os.path import join
        return self._values["sources"] \
            + [] if self.sourcedir is None else list(chain(*[
                glob(join(self.sourcedir, "*."+b))
                for b in ["c", "cpp", "cc", "C", "cxx"]]))
    @property
    def _objects(self):
        from itertools import chain
        return list(chain(
            (self._cxobject(source=a)._object for a in self._sources),
            self.objects))
    @property
    def _target(self):
        if self.target:
            return self.target
        if not self.name:
            return "a.out"
        if self._kind == "static":
            return "lib" + self.name + ".a"
        elif self._kind == "console":
            return self.name
    @property
    def _kind(self):
        return self.kind or "console"
    @property
    def _ldflags(self):
        from shlex import split
        from os import environ
        return self.ldflags + split(environ.get("LDFLAGS", ""))
    def _cxobject(self, **clobber):
        a = dict(filter(lambda (b, c): b in cxobject._params, self._values.iteritems()))
        a.update(clobber)
        return cxobject(**a)
    def add_to(self, rules):
        a = self._cxobject()
        for b in self._sources:
            a.source = b
            a.add_to(rules)
        if self.kind == "static":
            rules.add(
                [self._target],
                self._objects,
                [["ar", "crs", self._target] + self._objects],
                name=self.name)
        elif self.kind == "console":
            rules.add(
                [self._target],
                self._objects,
                [["gcc", "-o", self._target] + self._objects + ["-l"+a for a in self.libraries] + self._ldflags],
                name=self.name)
        else:
            assert False, "Invalid kind specified %r" % kind

def pkg_config(options, packages):
    from shlex import split
    from subprocess import Popen, PIPE
    return split(Popen(["pkg-config"] + options + packages, stdout=PIPE).communicate()[0])
